Skip to content

Implement a simple API for creating and registering FileFix-es#5257

Open
eclipseisoffline wants to merge 4 commits intoFabricMC:26.1from
eclipseisoffline:file-fix-utils
Open

Implement a simple API for creating and registering FileFix-es#5257
eclipseisoffline wants to merge 4 commits intoFabricMC:26.1from
eclipseisoffline:file-fix-utils

Conversation

@eclipseisoffline
Copy link
Copy Markdown

@eclipseisoffline eclipseisoffline commented Mar 15, 2026

This PR adds a simple API for creating and registering file-fixes to the game's FileFixerUpper.

The main use case for this API, as of right now, is to easily allow mod developers using SavedData to support loading pre-Minecraft 26.1 worlds in 26.1 and above. Minecraft 26.1 has made huge changes to the way Minecraft worlds are structured directory wise, and SavedDataType now uses a namespaced ID compared to a simple string:

-new SavedDataType<>("example_data", () -> new ExampleData(), CODEC, null);
+new SavedDataType<>(Identifier.fromNamespaceAndPath("example_mod", "example_data"), () -> new ExampleData(), ExampleData.CODEC, null);

This results in the following change in a world's directory layout:

-data/example_data.dat
+data/example_mod/example_data.dat
// or:
+dimensions/overworld/data/example_mod/example_data.dat

-DIM-1/data/example_data.dat
+dimensions/the_nether/data/example_mod/example_data.dat

-DIM1/data/example_data.dat
+dimensions/the_end/data/example_mod/example_data.dat

Note the "or" in there: SavedData can be used per-dimension, or globally/server-wide. In Minecraft 1.21.11 and below, server-wide SavedData was stored together with overworld SavedData in the root data folder. This was changed in Minecraft 26.1, overworld SavedData is now stored in its respective folder in the dimensions folder.

Vanilla Minecraft handles these path changes through FileFixes, which are registered at a FileFixerUpper instance (also see the DimensionStorageFileFix class). This API allows mod developers to easily create and register these FileFixes, so that they can provide support for loading worlds created in Minecraft 1.21.11 and below in 26.1.


Currently, developers can register a FileFix for a specific Schema version as follows:

FileFixSchemaRegisterCallback.registerFileFixes(4772, ExampleFileFix::new);

(where ExampleFileFix is a FileFix with ExampleFileFix(Schema) as constructor)

This method allows multiple FileFix constructors to be passed at once. For more flexibility, developers can also use the FileFixSchemaRegisterCallback#EVENT directly. File-fixes added by mods are added to a schema before vanilla's file fixes.

Developers can also make use of the utility methods present in the FileFixHelpers interface to create file-fixes or FileFixOperations. Currently, there are only methods to help aid the move to namespaced saved-data:

  • createDimensionDataMoveOperation and createGlobalDataMoveOperation: can be used to create a FileFixOperation to move one or multiple SavedData data files to their respective folders for their new namespaced IDs.
  • createDimensionDataMoveFileFix and createGlobalDataMoveFileFix: can be used to create a FileFix constructor to move one or multiple SavedData data files to their respective folders for their new namespaced IDs.
  • registerDimensionDataMoveFileFix and registerGlobalDataMoveFileFix: can be used to create and automatically register a FileFix constructor to move one or multiple SavedData data files to their respective folders for their new namespaced IDs. The file-fix is registered at schema 4772, in line with vanilla.

These methods make it easy for mod developers to support loading older worlds in Minecraft 26.1, without losing SavedData. This can be done as follows, for example:

-new SavedDataType<>("example_data", () -> new ExampleData(), CODEC, null);
-ExampleData myData = server.overworld().getDataStorage().computeIfAbsent(myGlobalDataType);

+Identifier id = Identifier.fromNamespaceAndPath("example_mod", "example_data");
+new SavedDataType<>(id, () -> new ExampleData(), ExampleData.CODEC, null);
+ExampleData myData = server.getDataStorage().computeIfAbsent(myGlobalDataType);
+FileFixHelpers.registerGlobalDataMoveFileFix("example_data", id);

@sylv256 sylv256 added the enhancement New feature or request label Mar 15, 2026
@eclipseisoffline
Copy link
Copy Markdown
Author

I have tested the registerDimensionDataMoveFileFix and registerGlobalDataMoveFileFix methods, which both seem to function properly when converting worlds with per-dimension and global saved-data (note: the dimension file fix was only tested with vanilla dimensions). I am marking the PR as ready for review as I think there is not much to be added to the API at this point in time, and it would be nice to get this in somewhat soon, so that mods can make use out of it when porting to 26.1.

One thing that I have noted during my testing is that registering callbacks for the FileFixSchemaRegisterCallback must be done in the pre-launch entrypoint, as the normal main entrypoint occurs after Minecraft's FileFixerUpper instance has been built.

@eclipseisoffline eclipseisoffline marked this pull request as ready for review March 22, 2026 10:22
Copy link
Copy Markdown
Member

@modmuss50 modmuss50 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is trying to do too much, at a glance this should just need to be a simple event. Please also add tests and update the data attachment api to use this.

Comment on lines +29 to +31
operations.addAll(FileFixHelpersImpl.createDimensionMoveOperations(saveIdMap, "", "dimensions/minecraft/overworld"));
operations.addAll(FileFixHelpersImpl.createDimensionMoveOperations(saveIdMap, "DIM-1", "dimensions/minecraft/the_nether"));
operations.addAll(FileFixHelpersImpl.createDimensionMoveOperations(saveIdMap, "DIM1", "dimensions/minecraft/the_end"));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should not hard code vanilla's own operations here.

import java.util.Map;
import java.util.function.Function;

public interface FileFixHelpers {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this an interface

import java.util.Map;
import java.util.function.Function;

public interface FileFixHelpers {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No docs

import java.util.function.Function;

@FunctionalInterface
public interface FileFixSchemaRegisterCallback {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No docs

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have now written documentation here


@SafeVarargs
static void registerFileFixes(int version, Function<Schema, FileFix>... fixes) {
EVENT.register((fileFixerUpper, schema, schemaVersion) -> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the point in this function, is it not cleaner for a mod to just use the event?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mods will usually want to register file fixes for only one data version at a time. This method functions as a shortcut for that. In my opinion, it is cleaner to write:

FileFixSchemaRegisterCallback.registerFileFixes(4772, FileFixA::new, FileFixB::new);
FileFixSchemaRegisterCallback.registerFileFixes(4773, FileFixC::new);

Rather than:

FileFixSchemaRegisterCallback.EVENT.register((fileFixerUpper, schema, version) -> {
	if (version == 4772) {
		fileFixerUpper.addFixer(new FileFixA(schema));
		fileFixerUpper.addFixer(new FileFixB(schema));
	} else if (version == 4773) {
		fileFixerUpper.addFixer(new FileFixC(schema));
	}
});

Although I do suppose the latter would lead to less callbacks being registered at the event.

Comment on lines +21 to +89
public interface FileFixHelpers {

static FileFixOperation createDimensionDataMoveOperation(String oldSaveId, Identifier newSaveId) {
return createDimensionDataMoveOperation(Map.of(oldSaveId, newSaveId));
}

static FileFixOperation createDimensionDataMoveOperation(Map<String, Identifier> saveIdMap) {
List<FileFixOperation> operations = new ArrayList<>();
operations.addAll(FileFixHelpersImpl.createDimensionMoveOperations(saveIdMap, "", "dimensions/minecraft/overworld"));
operations.addAll(FileFixHelpersImpl.createDimensionMoveOperations(saveIdMap, "DIM-1", "dimensions/minecraft/the_nether"));
operations.addAll(FileFixHelpersImpl.createDimensionMoveOperations(saveIdMap, "DIM1", "dimensions/minecraft/the_end"));
operations.add(FileFixHelpersImpl.createCustomDimensionDataMoveOperation(saveIdMap));
return new CombinedFileFixOperation(operations);
}

static Function<Schema, FileFix> createDimensionDataMoveFileFix(String oldSaveId, Identifier newSaveId) {
return createDimensionDataMoveFileFix(Map.of(oldSaveId, newSaveId));
}

static Function<Schema, FileFix> createDimensionDataMoveFileFix(Map<String, Identifier> saveIdMap) {
return schema -> new FileFix(schema) {
@Override
public void makeFixer() {
addFileFixOperation(createDimensionDataMoveOperation(saveIdMap));
}
};
}

static void registerDimensionDataMoveFileFix(String oldSaveId, Identifier newSaveId) {
registerDimensionDataMoveFileFix(Map.of(oldSaveId, newSaveId));
}

static void registerDimensionDataMoveFileFix(Map<String, Identifier> saveIdMap) {
FileFixSchemaRegisterCallback.registerFileFixes(FileFixerUpperAccessor.getFileFixerIntroductionVersion(), createDimensionDataMoveFileFix(saveIdMap));
}

static FileFixOperation createGlobalDataMoveOperation(String oldSaveId, Identifier newSaveId) {
return createGlobalDataMoveOperation(Map.of(oldSaveId, newSaveId));
}

static FileFixOperation createGlobalDataMoveOperation(Map<String, Identifier> saveIdMap) {
return FileFixOperations.applyInFolders(
FileRelation.DATA,
saveIdMap.entrySet().stream()
.map(entry -> FileFixHelpersImpl.createNamespacedDataMoveOperation(entry.getKey(), entry.getValue(), "", ""))
.toList()
);
}

static Function<Schema, FileFix> createGlobalDataMoveFileFix(String oldSaveId, Identifier newSaveId) {
return createGlobalDataMoveFileFix(Map.of(oldSaveId, newSaveId));
}

static Function<Schema, FileFix> createGlobalDataMoveFileFix(Map<String, Identifier> saveIdMap) {
return schema -> new FileFix(schema) {
@Override
public void makeFixer() {
addFileFixOperation(createGlobalDataMoveOperation(saveIdMap));
}
};
}

static void registerGlobalDataMoveFileFix(String oldSaveId, Identifier newSaveId) {
registerGlobalDataMoveFileFix(Map.of(oldSaveId, newSaveId));
}

static void registerGlobalDataMoveFileFix(Map<String, Identifier> saveIdMap) {
FileFixSchemaRegisterCallback.registerFileFixes(FileFixerUpperAccessor.getFileFixerIntroductionVersion(), createGlobalDataMoveFileFix(saveIdMap));
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd argue most of this file is out of scope, a mod can easily create its own file fix as required. Lets just keep the api simple.


@WrapOperation(method = "addFixers", at = @At(value = "INVOKE", target = "Lnet/minecraft/util/filefix/FileFixerUpper$Builder;addSchema(Lcom/mojang/datafixers/DataFixerBuilder;ILjava/util/function/BiFunction;)Lcom/mojang/datafixers/schemas/Schema;"))
private static Schema runSchemaRegisterCallback(FileFixerUpper.Builder instance, DataFixerBuilder fixerUpper, int version, BiFunction<Integer, Schema, Schema> factory, Operation<Schema> original) {
Schema schema = original.call(instance, fixerUpper, version, factory);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whats the schema used for?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schema is passed to the FileFixSchemaRegisterCallback event. Mods will need the schema to create file fix instances.

@Mixin(DataFixers.class)
public abstract class DataFixersMixin {

@WrapOperation(method = "addFixers", at = @At(value = "INVOKE", target = "Lnet/minecraft/util/filefix/FileFixerUpper$Builder;addSchema(Lcom/mojang/datafixers/DataFixerBuilder;ILjava/util/function/BiFunction;)Lcom/mojang/datafixers/schemas/Schema;"))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this not target every addSchema in the function? Why can it not just be a simple @Inject at the end of the function.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will, and that is intentional. Fixes added to a FileFixerUpper are added to the last schema registered at it. Placing an inject at the of the method would limit mods to only be able to add file fixes to the very last schema registered at the FileFixerUpper, which is not how it is intended to be used.

@eclipseisoffline
Copy link
Copy Markdown
Author

Apologies for the lack of documentation at first - I kind of forgot, whoops. I've now written documentation for the FileFixSchemaRegisterCallback interface.

The idea with the FileFixHelpers utility interface was to provide commonly used file fixes to mod developers, so that they don't have to create these themselves. Every mod using SavedData will have to create a file fix that is almost exactly the same in most use cases, except for the identifiers being different. This interface would help get rid of some of the code duplication there. However, I understand that it may be too much for FAPI. I am fine with it being removed in that case.

Over all I am less sure on how good of an API this is. The core problem is that file fixes have to be registered before ModInitializers are run. Registering them in a PreLaunchEntrypoint generally isn't a good idea, so I'm not sure what a good solution here is.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants